/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 * @format
 * @oncall react_native
 */

'use strict';

/* eslint-disable no-bitwise */

// $FlowFixMe[cannot-resolve-module]: not defined by Flow
const constants = require('constants');
const {EventEmitter} = require('events');
const stream = require('stream');

type NodeBase = {
  gid: number,
  id: number,
  mode: number,
  uid: number,
  watchers: Array<NodeWatcher>,
};

type DirectoryNode = {
  ...NodeBase,
  type: 'directory',
  entries: Map<string, EntityNode>,
};

type FileNode = {
  ...NodeBase,
  type: 'file',
  content: Buffer,
};

type SymbolicLinkNode = {
  ...NodeBase,
  type: 'symbolicLink',
  target: string,
};

type EntityNode = DirectoryNode | FileNode | SymbolicLinkNode;

type NodeWatcher = {
  recursive: boolean,
  listener: (eventType: 'change' | 'rename', filePath: string) => void,
  ...
};

type Encoding =
  | 'ascii'
  | 'base64'
  | 'binary'
  | 'buffer'
  | 'hex'
  | 'latin1'
  | 'ucs2'
  | 'utf16le'
  | 'utf8';

type Resolution = {
  +basename: string,
  +dirNode: DirectoryNode,
  +dirPath: Array<[string, EntityNode]>,
  +drive: string,
  +node: ?EntityNode,
  +realpath: string,
};

type Descriptor = {
  +nodePath: Array<[string, EntityNode]>,
  +node: FileNode,
  +readable: boolean,
  +writable: boolean,
  position: number,
};

type FilePath = string | Buffer;

const kWritableMustExist = Symbol('kWritableMustExist');

type InternalOpenFlags = string | typeof kWritableMustExist;

const FLAGS_SPECS: {
  [InternalOpenFlags]: {
    exclusive?: true,
    mustExist?: true,
    readable?: true,
    truncate?: true,
    writable?: true,
    ...
  },
  ...
} = {
  r: {mustExist: true, readable: true},
  'r+': {mustExist: true, readable: true, writable: true},
  'rs+': {mustExist: true, readable: true, writable: true},
  w: {truncate: true, writable: true},
  wx: {exclusive: true, truncate: true, writable: true},
  'w+': {readable: true, truncate: true, writable: true},
  'wx+': {exclusive: true, readable: true, truncate: true, writable: true},
  // $FlowFixMe[invalid-computed-prop]: Symbol support is incomplete
  [kWritableMustExist]: {mustExist: true, writable: true},
};

const ASYNC_FUNC_NAMES = [
  'access',
  'chmod',
  'close',
  'copyFile',
  'fchmod',
  'fstat',
  'fsync',
  'fdatasync',
  'lchmod',
  'link',
  'lstat',
  'mkdir',
  'mkdtemp',
  'open',
  'read',
  'readdir',
  'readFile',
  'readlink',
  'realpath',
  'rename',
  'stat',
  'truncate',
  'unlink',
  'write',
  'writeFile',
];

const NATIVE_FUNC_NAMES = ['realpath', 'realpathSync'];

// Generated by `node -p "Object.keys(require('fs').promises).sort()"`
const PROMISE_FUNC_NAMES = [
  'access',
  'appendFile',
  'chmod',
  'chown',
  'copyFile',
  'lchmod',
  'lchown',
  'link',
  'lstat',
  'mkdir',
  'mkdtemp',
  'open',
  'readFile',
  'readdir',
  'readlink',
  'realpath',
  'rename',
  'rmdir',
  'stat',
  'symlink',
  'truncate',
  'unlink',
  'utimes',
  'writeFile',
];

type Options = {
  /**
   * On a win32 FS, there will be drives at the root, like "C:\". On a Posix FS,
   * there is only one root "/".
   */
  platform?: 'win32' | 'posix',
  /**
   * To be able to use relative paths, this function must provide the current
   * working directory. A possible implementation is to forward `process.cwd`,
   * but one must ensure to create that directory in the memory FS (no
   * directory is ever created automatically).
   */
  cwd?: () => string,
  ...
};

/**
 * Simulates `fs` API in an isolated, memory-based filesystem. This is useful
 * for testing systems that rely on `fs` without affecting the real filesystem.
 * This is meant to be a drop-in replacement/mock for `fs`, so it mimics
 * closely the behavior of file path resolution and file accesses.
 */
class MemoryFs {
  _roots: Map<string, DirectoryNode>;
  _fds: Map<number, Descriptor>;
  _nextId: number;
  _platform: 'win32' | 'posix';
  _pathSep: string;
  _cwd: ?() => string;
  constants: any = constants;
  promises: {[funcName: string]: (...args: Array<any>) => Promise<any>, ...};
  Dirent: typeof Dirent = Dirent;

  close: (fd: number, callback: (error: ?Error) => mixed) => void;
  copyFile: ((
    src: FilePath,
    dest: FilePath,
    callback: (error: Error) => mixed,
  ) => void) &
    ((
      src: FilePath,
      dest: FilePath,
      flags?: number,
      callback: (error: ?Error) => mixed,
    ) => void);
  open: (
    filePath: FilePath,
    flag: string | number,
    mode?: number,
    callback: (error: ?Error, fd: ?number) => mixed,
  ) => void;
  read: (
    fd: number,
    buffer: Buffer,
    offset: number,
    length: number,
    position: ?number,
    callback: (?Error, ?number) => mixed,
  ) => void;
  readFile: (
    filePath: FilePath,
    options?:
      | {
          encoding?: Encoding,
          flag?: string,
          ...
        }
      | Encoding
      | ((?Error, ?Buffer | string) => mixed),
    callback?: (?Error, ?Buffer | string) => mixed,
  ) => void;
  realpath: (filePath: FilePath, callback: (?Error, ?string) => mixed) => void;
  write: (
    fd: number,
    bufferOrString: Buffer | string,
    offsetOrPosition?: number | ((?Error, number) => mixed),
    lengthOrEncoding?: number | string | ((?Error, number) => mixed),
    position?: number | ((?Error, number) => mixed),
    callback?: (?Error, number) => mixed,
  ) => void;
  writeFile: (
    filePath: FilePath,
    data: Buffer | string,
    options?:
      | {
          encoding?: ?Encoding,
          mode?: ?number,
          flag?: ?string,
          ...
        }
      | Encoding
      | ((?Error) => mixed),
    callback?: (?Error) => mixed,
  ) => void;

  constructor(options?: ?Options) {
    this._platform = (options && options.platform) || 'posix';
    this._cwd = options && options.cwd;
    this._pathSep = this._platform === 'win32' ? '\\' : '/';
    this.reset();
    ASYNC_FUNC_NAMES.forEach(funcName => {
      const func = (this as $FlowFixMe)[`${funcName}Sync`];
      (this as $FlowFixMe)[funcName] = function (...args) {
        const callback = args.pop();
        process.nextTick(() => {
          let retval;
          try {
            retval = func.apply(null, args);
          } catch (error) {
            callback(error);
            return;
          }
          callback(null, retval);
        });
      };
    });
    NATIVE_FUNC_NAMES.forEach(funcName => {
      const func = (this as $FlowFixMe)[funcName];
      func.native = func;
    });
    this.promises = PROMISE_FUNC_NAMES.filter(
      // $FlowFixMe[prop-missing]: No indexer
      funcName => typeof this[`${funcName}Sync`] === 'function',
    ).reduce<{[string]: (...args: Array<any>) => Promise<any>}>(
      (promises, funcName) => {
        promises[funcName] = (...args) =>
          new Promise((resolve, reject) => {
            try {
              // $FlowFixMe[prop-missing]: No indexer
              resolve(this[`${funcName}Sync`](...args));
            } catch (error) {
              reject(error);
            }
          });

        return promises;
      },
      {},
    );
  }

  reset() {
    this._nextId = 1;
    this._roots = new Map();
    if (this._platform === 'posix') {
      this._roots.set('', this._makeDir(0o777));
    } else if (this._platform === 'win32') {
      this._roots.set('C:', this._makeDir(0o777));
    }
    this._fds = new Map();
  }

  accessSync: (filePath: FilePath, mode?: number) => void = (
    filePath: FilePath,
    mode?: number,
  ): void => {
    if (mode == null) {
      mode = constants.F_OK;
    }
    const stats = this.statSync(filePath);
    if (mode == constants.F_OK) {
      return;
    }
    const filePathStr = pathStr(filePath);
    if ((mode & constants.R_OK) !== 0) {
      if (
        !(
          (stats.mode & constants.S_IROTH) !== 0 ||
          ((stats.mode & constants.S_IRGRP) !== 0 && stats.gid === getgid()) ||
          ((stats.mode & constants.S_IRUSR) !== 0 && stats.uid === getuid())
        )
      ) {
        throw makeError('EPERM', filePathStr, 'file cannot be read');
      }
    }
    if ((mode & constants.W_OK) !== 0) {
      if (
        !(
          (stats.mode & constants.S_IWOTH) !== 0 ||
          ((stats.mode & constants.S_IWGRP) !== 0 && stats.gid === getgid()) ||
          ((stats.mode & constants.S_IWUSR) !== 0 && stats.uid === getuid())
        )
      ) {
        throw makeError('EPERM', filePathStr, 'file cannot be written to');
      }
    }
    if ((mode & constants.X_OK) !== 0) {
      if (
        !(
          (stats.mode & constants.S_IXOTH) !== 0 ||
          ((stats.mode & constants.S_IXGRP) !== 0 && stats.gid === getgid()) ||
          ((stats.mode & constants.S_IXUSR) !== 0 && stats.uid === getuid())
        )
      ) {
        throw makeError('EPERM', filePathStr, 'file cannot be executed');
      }
    }
  };

  chmodSync: (filePath: FilePath, mode: string | number) => void = (
    filePath: FilePath,
    mode: string | number,
  ): void => {
    filePath = pathStr(filePath);
    mode = parseFileMode(mode);
    const {node} = this._resolve(filePath);
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    node.mode = mode;
  };

  closeSync: (fd: number) => void = (fd: number): void => {
    const desc = this._getDesc(fd);
    if (desc.writable) {
      this._emitFileChange(desc.nodePath.slice(), {eventType: 'change'});
    }
    this._fds.delete(fd);
  };

  copyFileSync: (src: FilePath, dest: FilePath, flags?: number) => void = (
    src: FilePath,
    dest: FilePath,
    flags?: number = 0,
  ) => {
    const options = flags & constants.COPYFILE_EXCL ? {flag: 'wx'} : {};
    /* $FlowFixMe[incompatible-type] Natural Inference rollout. See
     * https://fburl.com/gdoc/y8dn025u */
    this.writeFileSync(dest, this.readFileSync(src), options);
  };

  fchmodSync: (fd: number, mode: string | number) => void = (
    fd: number,
    mode: string | number,
  ): void => {
    mode = parseFileMode(mode);
    const {node} = this._getDesc(fd);
    node.mode = mode;
  };

  fsyncSync: (fd: number) => void = (fd: number): void => {
    this._getDesc(fd);
  };

  fdatasyncSync: (fd: number) => void = (fd: number): void => {
    this._getDesc(fd);
  };

  lchmodSync: (filePath: FilePath, mode: string | number) => void = (
    filePath: FilePath,
    mode: string | number,
  ): void => {
    filePath = pathStr(filePath);
    mode = parseFileMode(mode);
    const {node} = this._resolve(filePath, {keepFinalSymlink: true});
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    node.mode = mode;
  };

  openSync: (
    filePath: FilePath,
    flags: string | number,
    mode?: number,
  ) => number = (
    filePath: FilePath,
    flags: string | number,
    mode?: number,
  ): number => {
    if (typeof flags === 'number') {
      throw new Error(`numeric flags not supported: ${flags}`);
    }
    return this._open(pathStr(filePath), flags, mode);
  };

  readSync: (
    fd: number,
    buffer: Buffer,
    offset: number,
    length: number,
    position: ?number,
  ) => number = (
    fd: number,
    buffer: Buffer,
    offset: number,
    length: number,
    position: ?number,
  ): number => {
    const desc = this._getDesc(fd);
    if (!desc.readable) {
      throw makeError('EBADF', null, 'file descriptor cannot be written to');
    }
    if (position != null) {
      desc.position = position;
    }
    const endPos = Math.min(desc.position + length, desc.node.content.length);
    desc.node.content.copy(buffer, offset, desc.position, endPos);
    const bytesRead = endPos - desc.position;
    desc.position = endPos;
    return bytesRead;
  };

  readdirSync: (
    filePath: FilePath,
    options?: {encoding?: Encoding, withFileTypes?: boolean, ...} | Encoding,
  ) => Array<string | Buffer | Dirent> = (
    filePath: FilePath,
    options?: {encoding?: Encoding, withFileTypes?: boolean, ...} | Encoding,
  ): Array<string | Buffer | Dirent> => {
    let encoding, withFileTypes;
    if (typeof options === 'string') {
      encoding = options;
    } else if (options != null) {
      ({encoding, withFileTypes} = options);
    }
    filePath = pathStr(filePath);
    const {node} = this._resolve(filePath);
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    if (node.type !== 'directory') {
      throw makeError('ENOTDIR', filePath, 'not a directory');
    }
    return Array.from(node.entries.keys()).map(str => {
      let name;
      if (encoding === 'utf8') {
        name = str;
      } else {
        const buffer = Buffer.from(str);
        if (encoding === 'buffer') {
          name = buffer;
        } else {
          name = buffer.toString(encoding);
        }
      }
      if (withFileTypes) {
        return new Dirent(nullthrows(node.entries.get(str)), name);
      }
      return name;
    });
  };

  readFileSync: (
    filePath: FilePath,
    options?:
      | {
          encoding?: Encoding,
          flag?: string,
          ...
        }
      | Encoding,
  ) => Buffer | string = (
    filePath: FilePath,
    options?:
      | {
          encoding?: Encoding,
          flag?: string,
          ...
        }
      | Encoding,
  ): Buffer | string => {
    let encoding, flag;
    if (typeof options === 'string') {
      encoding = options;
    } else if (options != null) {
      ({encoding, flag} = options);
    }
    const fd = this._open(pathStr(filePath), flag || 'r');
    const chunks = [];
    try {
      const buffer = Buffer.alloc(1024);
      let bytesRead;
      do {
        bytesRead = this.readSync(fd, buffer, 0, buffer.length, null);
        if (bytesRead === 0) {
          continue;
        }
        const chunk = Buffer.alloc(bytesRead);
        buffer.copy(chunk, 0, 0, bytesRead);
        chunks.push(chunk);
      } while (bytesRead > 0);
    } finally {
      this.closeSync(fd);
    }
    const result = Buffer.concat(chunks);
    if (encoding == null) {
      return result;
    }
    return result.toString(encoding);
  };

  readlinkSync: (
    filePath: FilePath,
    options: ?(Encoding | {encoding: ?Encoding, ...}),
  ) => string | Buffer = (
    filePath: FilePath,
    options: ?Encoding | {encoding: ?Encoding, ...},
  ): string | Buffer => {
    let encoding;
    if (typeof options === 'string') {
      encoding = options;
    } else if (options != null) {
      ({encoding} = options);
    }
    filePath = pathStr(filePath);
    const {node} = this._resolve(filePath, {keepFinalSymlink: true});
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    if (node.type !== 'symbolicLink') {
      throw makeError('EINVAL', filePath, 'entity is not a symlink');
    }
    if (encoding == null || encoding === 'utf8') {
      return node.target;
    }
    const buf = Buffer.from(node.target);
    if (encoding == 'buffer') {
      return buf;
    }
    return buf.toString(encoding);
  };

  realpathSync: (filePath: FilePath) => string = (
    filePath: FilePath,
  ): string => {
    return this._resolve(pathStr(filePath)).realpath;
  };

  writeSync: (
    fd: number,
    bufferOrString: Buffer | string,
    offsetOrPosition?: number,
    lengthOrEncoding?: number | string,
    position?: number,
  ) => number = (
    fd: number,
    bufferOrString: Buffer | string,
    offsetOrPosition?: number,
    lengthOrEncoding?: number | string,
    position?: number,
  ): number => {
    let encoding, offset, length, buffer;
    if (typeof bufferOrString === 'string') {
      position = offsetOrPosition;
      encoding = lengthOrEncoding;
      buffer = (Buffer as $FlowFixMe).from(
        bufferOrString,
        (encoding as $FlowFixMe) || 'utf8',
      );
    } else {
      offset = offsetOrPosition;
      if (lengthOrEncoding != null && typeof lengthOrEncoding !== 'number') {
        throw new Error('invalid length');
      }
      length = lengthOrEncoding;
      buffer = bufferOrString;
    }
    if (offset == null) {
      offset = 0;
    }
    if (length == null) {
      length = buffer.length;
    }
    return this._write(fd, buffer, offset, length, position);
  };

  writeFileSync: (
    filePathOrFd: FilePath | number,
    data: Buffer | string,
    options?:
      | {
          encoding?: ?Encoding,
          flag?: ?string,
          mode?: ?number,
          ...
        }
      | Encoding,
  ) => void = (
    filePathOrFd: FilePath | number,
    data: Buffer | string,
    options?:
      | {
          encoding?: ?Encoding,
          mode?: ?number,
          flag?: ?string,
          ...
        }
      | Encoding,
  ): void => {
    let encoding, mode, flag;
    if (typeof options === 'string') {
      encoding = options;
    } else if (options != null) {
      ({encoding, mode, flag} = options);
    }
    if (encoding == null) {
      encoding = 'utf8';
    }
    if (typeof data === 'string') {
      data = (Buffer as $FlowFixMe).from(data, encoding);
    }
    const fd: number =
      typeof filePathOrFd === 'number'
        ? filePathOrFd
        : this._open(pathStr(filePathOrFd), flag || 'w', mode);
    try {
      this._write(fd, data, 0, data.length);
    } finally {
      if (typeof filePathOrFd !== 'number') {
        this.closeSync(fd);
      }
    }
  };

  mkdirSync: (
    dirPath: string | Buffer,
    options?: number | {recursive?: boolean, mode?: number},
  ) => void = (
    dirPath: string | Buffer,
    options?: number | {recursive?: boolean, mode?: number},
  ): void => {
    const recursive = typeof options != 'number' && options?.recursive;
    const mode =
      (typeof options == 'number' ? options : options?.mode) ?? 0o777;

    dirPath = pathStr(dirPath);
    if (recursive) {
      const {drive, entNames} = this._parsePathWithCwd(dirPath);
      const root = this._getRoot(drive, dirPath);
      const context = {
        drive,
        node: root,
        nodePath: [['', root]],
        entNames,
        symlinkCount: 0,
        keepFinalSymlink: false,
      };

      while (context.entNames.length > 0) {
        const entName = context.entNames.shift();
        this._resolveEnt(context, dirPath, entName);
        if (context.node == null) {
          const [_parentName, parentNode] =
            context.nodePath[context.nodePath.length - 2];
          const childPair = context.nodePath[context.nodePath.length - 1];
          /* $FlowFixMe[invalid-compare] Error discovered during Constant
           * Condition roll out. See https://fburl.com/workplace/4oq3zi07. */
          if (parentNode && parentNode.type === 'directory') {
            context.node = this._makeDir(mode);
            parentNode.entries.set(entName, context.node);
            childPair[1] = context.node;
          } else {
            throw makeError(
              'EEXIST',
              dirPath,
              'directory or file already exists',
            );
          }
        }
      }
    } else {
      const {dirNode, node, basename} = this._resolve(dirPath);
      if (node != null) {
        throw makeError('EEXIST', dirPath, 'directory or file already exists');
      }
      dirNode.entries.set(basename, this._makeDir(mode));
    }
  };

  mkdtempSync: (
    prefix: string,
    options?:
      | {
          encoding?: ?Encoding,
          ...
        }
      | Encoding,
  ) => string = (
    prefix: string,
    options?:
      | {
          encoding?: ?Encoding,
          ...
        }
      | Encoding,
  ): string => {
    const encoding =
      (typeof options === 'string' ? options : options?.encoding) ?? 'utf8';
    const ALPHABET =
      'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_';
    const RANDOM_PART_LENGTH = 6;
    let dirPath = '';
    do {
      let randomPart = '';
      for (let i = 0; i < RANDOM_PART_LENGTH; ++i) {
        randomPart += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
      }
      dirPath = prefix + randomPart;
    } while (this.existsSync(dirPath));
    this.mkdirSync(dirPath, 0o700);
    return Buffer.from(dirPath, 'utf8').toString(encoding);
  };

  rmdirSync: (dirPath: string | Buffer) => void = (
    dirPath: string | Buffer,
  ): void => {
    dirPath = pathStr(dirPath);
    const {dirNode, node, basename} = this._resolve(dirPath);
    if (node == null) {
      throw makeError('ENOENT', dirPath, 'directory does not exist');
    } else if (node.type === 'file') {
      if (this._platform === 'posix') {
        throw makeError('ENOTDIR', dirPath, 'cannot rm a file');
      } else {
        throw makeError('ENOENT', dirPath, 'cannot rm a file');
      }
    } else if (node.type === 'directory' && node.entries.size) {
      throw makeError('ENOTEMPTY', dirPath, 'directory not empty');
    }
    dirNode.entries.delete(basename);
  };

  symlinkSync: (
    target: string | Buffer,
    filePath: FilePath,
    type?: string,
  ) => void = (target: string | Buffer, filePath: FilePath, type?: string) => {
    if (type == null) {
      type = 'file';
    }
    if (type !== 'file') {
      throw new Error('symlink type not supported');
    }
    filePath = pathStr(filePath);
    const {dirNode, node, basename} = this._resolve(filePath);
    if (node != null) {
      throw makeError('EEXIST', filePath, 'directory or file already exists');
    }
    dirNode.entries.set(basename, {
      id: this._getId(),
      gid: getgid(),
      target: pathStr(target),
      mode: 0o666,
      uid: getuid(),
      type: 'symbolicLink',
      watchers: [],
    });
  };

  existsSync: (filePath: FilePath) => boolean = (
    filePath: FilePath,
  ): boolean => {
    try {
      const {node} = this._resolve(pathStr(filePath));
      return node != null;
    } catch (error) {
      if (error.code === 'ENOENT') {
        return false;
      }
      throw error;
    }
  };

  statSync: (filePath: FilePath) => Stats = (filePath: FilePath) => {
    filePath = pathStr(filePath);
    const {node} = this._resolve(filePath);
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    return new Stats(node);
  };

  lstatSync: (filePath: FilePath) => Stats = (filePath: FilePath) => {
    filePath = pathStr(filePath);
    const {node} = this._resolve(filePath, {
      keepFinalSymlink: true,
    });
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    return new Stats(node);
  };

  fstatSync: (fd: number) => Stats = (fd: number) => {
    const desc = this._getDesc(fd);
    return new Stats(desc.node);
  };

  createReadStream: (
    filePath: FilePath,
    options?:
      | {
          autoClose?: ?boolean,
          encoding?: ?Encoding,
          end?: ?number,
          fd?: ?number,
          flags?: ?string,
          highWaterMark?: ?number,
          mode?: ?number,
          start?: ?number,
          ...
        }
      | Encoding,
  ) => ReadFileSteam = (
    filePath: FilePath,
    options?:
      | {
          autoClose?: ?boolean,
          encoding?: ?Encoding,
          end?: ?number,
          fd?: ?number,
          flags?: ?string,
          highWaterMark?: ?number,
          mode?: ?number,
          start?: ?number,
          ...
        }
      | Encoding,
  ) => {
    let autoClose, encoding, fd, flags, mode, start, end, highWaterMark;
    if (typeof options === 'string') {
      encoding = options;
    } else if (options != null) {
      ({autoClose, encoding, fd, flags, mode, start} = options);
      ({end, highWaterMark} = options);
    }
    let st = null;
    if (fd == null) {
      fd = this._open(pathStr(filePath), flags || 'r', mode);
      process.nextTick(() => (st as any).emit('open', fd));
    }
    const ffd = fd;
    const {readSync} = this;
    const rst = new ReadFileSteam({
      filePath,
      encoding,
      fd,
      highWaterMark,
      start,
      end,
      readSync,
    });
    st = rst;
    if (autoClose !== false) {
      const doClose = () => {
        this.closeSync(ffd);
        rst.emit('close');
      };
      rst.on('end', doClose);
      rst.on('error', doClose);
    }
    return rst;
  };

  unlinkSync: (filePath: FilePath) => void = (filePath: FilePath) => {
    filePath = pathStr(filePath);
    const {basename, dirNode, dirPath, node} = this._resolve(filePath, {
      keepFinalSymlink: true,
    });
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    if (node.type !== 'file' && node.type !== 'symbolicLink') {
      throw makeError('EISDIR', filePath, 'cannot unlink a directory');
    }
    dirNode.entries.delete(basename);
    this._emitFileChange(dirPath.concat([[basename, node]]), {
      eventType: 'rename',
    });
  };

  rmSync: (
    filePath: FilePath,
    options?: {recursive?: boolean, force?: boolean},
  ) => void = (filePath: FilePath, options) => {
    filePath = pathStr(filePath);
    const {dirNode, node, basename} = this._resolve(filePath, {
      keepFinalSymlink: true,
    });
    if (node == null) {
      if (options?.force !== true) {
        throw makeError('ENOENT', filePath, 'no such file or directory');
      }
    } else if (node.type === 'directory') {
      if (options && options.recursive) {
        // NOTE: File watchers won't be informed of recursive deletions
        dirNode.entries.delete(basename);
      } else {
        this.rmdirSync(filePath);
      }
    } else {
      this.unlinkSync(filePath);
    }
  };

  renameSync: (oldPath: FilePath, newPath: FilePath) => void = (
    oldPath,
    newPath,
  ) => {
    oldPath = pathStr(oldPath);
    newPath = pathStr(newPath);
    const {
      basename: oldBasename,
      dirNode: oldDirNode,
      dirPath: oldDirPath,
      node: node,
    } = this._resolve(oldPath, {
      keepFinalSymlink: true,
    });
    if (node == null) {
      throw makeError('ENOENT', oldPath, 'no such file or directory');
    }
    const {
      basename: newBasename,
      dirNode: newDirNode,
      dirPath: newDirPath,
      node: existingDestNode,
    } = this._resolve(newPath, {keepFinalSymlink: true});
    if (existingDestNode === node) {
      return;
    }
    if (newDirPath.some(([, nodeInNewPath]) => nodeInNewPath === node)) {
      throw makeError(
        'EINVAL',
        newPath,
        'cannot make a directory a subdirectory of itself',
      );
    }
    if (existingDestNode) {
      if (existingDestNode.type === 'directory') {
        if (existingDestNode.entries.size) {
          throw makeError('ENOTEMPTY', newPath, 'directory not empty');
        }
      } else if (node.type === 'directory') {
        throw makeError(
          'EISDIR',
          newPath,
          'cannot overwrite a directory with a non-directory',
        );
      }
    }
    newDirNode.entries.set(newBasename, node);
    if (existingDestNode) {
      // The existing node has been removed.
      this._emitFileChange(
        newDirPath.concat([[newBasename, existingDestNode]]),
        {
          eventType: 'rename',
        },
      );
    }
    // The source node has been linked at the new path.
    this._emitFileChange(newDirPath.concat([[newBasename, node]]), {
      eventType: 'rename',
    });
    oldDirNode.entries.delete(oldBasename);
    // The source node has been unlinked at the old path.
    this._emitFileChange(oldDirPath.concat([[oldBasename, node]]), {
      eventType: 'rename',
    });
  };

  linkSync: (oldPath: FilePath, newPath: FilePath) => void = (
    oldPath,
    newPath,
  ) => {
    oldPath = pathStr(oldPath);
    newPath = pathStr(newPath);
    const {node: node} = this._resolve(oldPath);
    if (node == null) {
      throw makeError('ENOENT', oldPath, 'no such file or directory');
    }
    if (node.type === 'directory') {
      throw makeError(
        'EPERM',
        oldPath,
        'cannot create a hard link to a directory',
      );
    }
    const {
      basename: newBasename,
      dirNode: newDirNode,
      dirPath: newDirPath,
      node: existingDestNode,
    } = this._resolve(newPath);
    if (existingDestNode) {
      throw makeError('EEXIST', newPath, 'destination path already exists');
    }
    newDirNode.entries.set(newBasename, node);
    this._emitFileChange(newDirPath.concat([[newBasename, node]]), {
      eventType: 'rename',
    });
  };

  truncateSync: (filePathOrFd: FilePath | number, length?: number) => void = (
    filePathOrFd: FilePath | number,
    length: number = 0,
  ): void => {
    const fd: number =
      typeof filePathOrFd === 'number'
        ? filePathOrFd
        : this._open(pathStr(filePathOrFd), kWritableMustExist);
    try {
      const desc = this._getDesc(fd);
      if (!desc.writable) {
        throw makeError('EBADF', null, 'file descriptor cannot be written to');
      }
      const {node, nodePath} = desc;
      const oldContent = node.content;
      node.content = Buffer.alloc(length, 0);
      oldContent.copy(
        node.content,
        0,
        0,
        Math.max(0, Math.min(length, oldContent.length)),
      );
      this._emitFileChange(nodePath.slice(), {
        eventType: 'change',
      });
    } finally {
      if (typeof filePathOrFd !== 'number') {
        this.closeSync(fd);
      }
    }
  };

  createWriteStream: (
    filePath: FilePath,
    options?:
      | {
          autoClose?: boolean,
          encoding?: Encoding,
          fd?: ?number,
          flags?: string,
          mode?: number,
          start?: number,
          emitClose?: boolean,
          ...
        }
      | Encoding,
  ) => WriteFileStream = (
    filePath: FilePath,
    options?:
      | {
          autoClose?: boolean,
          encoding?: Encoding,
          fd?: ?number,
          emitClose?: boolean,
          flags?: string,
          mode?: number,
          start?: number,
          ...
        }
      | Encoding,
  ) => {
    let autoClose, fd, flags, mode, start, emitClose;
    if (typeof options !== 'string' && options != null) {
      ({autoClose, fd, flags, mode, start, emitClose} = options);
    }
    let st = null;
    if (fd == null) {
      fd = this._open(pathStr(filePath), flags || 'w', mode);
      process.nextTick(() => (st as any).emit('open', fd));
    }
    const ffd = fd;
    const rst = new WriteFileStream({
      fd,
      // $FlowFixMe[method-unbinding] added when improving typing for this parameters
      writeSync: this._write.bind(this),
      filePath,
      start,
      emitClose: emitClose ?? false,
    });
    st = rst;
    if (autoClose !== false) {
      const doClose = () => {
        this.closeSync(ffd);
      };
      rst.on('finish', doClose);
      rst.on('error', doClose);
    }
    return st;
  };

  watch: (
    filePath: FilePath,
    options?:
      | {
          encoding?: Encoding,
          persistent?: boolean,
          recursive?: boolean,
          ...
        }
      | Encoding,
    listener?: (
      eventType: 'rename' | 'change',
      filePath: ?(string | Buffer),
    ) => mixed,
  ) => FSWatcher = (
    filePath: FilePath,
    options?:
      | {
          encoding?: Encoding,
          recursive?: boolean,
          persistent?: boolean,
          ...
        }
      | Encoding,
    listener?: (
      eventType: 'rename' | 'change',
      filePath: ?string | Buffer,
    ) => mixed,
  ) => {
    filePath = pathStr(filePath);
    const {node} = this._resolve(filePath);
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    let encoding, recursive, persistent;
    if (typeof options === 'string') {
      encoding = options;
    } else if (options != null) {
      ({encoding, recursive, persistent} = options);
    }
    const watcher = new FSWatcher(node, {
      encoding: encoding != null ? encoding : 'utf8',
      recursive: recursive != null ? recursive : false,
      persistent: persistent != null ? persistent : false,
    });
    if (listener != null) {
      watcher.on('change', listener);
    }
    return watcher;
  };

  _makeDir(mode: number): DirectoryNode {
    return {
      entries: new Map(),
      gid: getgid(),
      id: this._getId(),
      mode,
      uid: getuid(),
      type: 'directory',
      watchers: [],
    };
  }

  _getId(): number {
    return ++this._nextId;
  }

  _open(filePath: string, flags: InternalOpenFlags, mode: ?number): number {
    if (mode == null) {
      mode = 0o666;
    }
    const spec = FLAGS_SPECS[flags];
    if (spec == null) {
      throw new Error(`flags not supported: \`${flags.toString()}\``);
    }
    // $FlowFixMe[incompatible-type]
    const {writable = false, readable = false} = spec;
    const {exclusive, mustExist, truncate} = spec;
    let {dirNode, node, basename, dirPath} = this._resolve(filePath);
    let nodePath: Array<[string, EntityNode]>;
    if (node == null) {
      if (mustExist) {
        throw makeError('ENOENT', filePath, 'no such file or directory');
      }
      node = {
        content: Buffer.alloc(0),
        gid: getgid(),
        id: this._getId(),
        mode,
        uid: getuid(),
        type: 'file' as const,
        watchers: [],
      };
      dirNode.entries.set(basename, node);
      nodePath = dirPath.concat([[basename, node]]);
      this._emitFileChange(nodePath.slice(), {eventType: 'rename'});
    } else {
      if (exclusive) {
        throw makeError('EEXIST', filePath, 'directory or file already exists');
      }
      if (node.type !== 'file') {
        throw makeError('EISDIR', filePath, 'cannot read/write to a directory');
      }
      if (truncate) {
        node.content = Buffer.alloc(0);
      }
      nodePath = dirPath.concat([[basename, node]]);
    }
    return this._getFd(filePath, {
      nodePath,
      node,
      position: 0,
      readable,
      writable,
    });
  }

  _parsePath(filePath: string): {
    +drive: ?string,
    +entNames: Array<string>,
  } {
    let drive;
    const sep = this._platform === 'win32' ? /[\\/]/ : /\//;
    if (this._platform === 'win32' && filePath.match(/^[a-zA-Z]:[\\/]/)) {
      drive = filePath.substring(0, 2);
      filePath = filePath.substring(3);
    }
    if (sep.test(filePath[0])) {
      if (this._platform === 'posix') {
        drive = '';
        filePath = filePath.substring(1);
      } else {
        throw makeError(
          'EINVAL',
          filePath,
          'path is invalid because it cannot start with a separator',
        );
      }
    }
    return {entNames: filePath.split(sep), drive};
  }

  _parsePathWithCwd(filePath: string): {
    +drive: string,
    +entNames: Array<string>,
  } {
    let {drive, entNames} = this._parsePath(filePath);
    if (drive == null) {
      const {_cwd} = this;
      if (_cwd == null) {
        throw new Error(
          `The path \`${filePath}\` cannot be resolved because no ` +
            'current working directory function has been specified. Set the ' +
            '`cwd` option field to specify a current working directory.',
        );
      }
      const cwPath = this._parsePath(_cwd());
      drive = cwPath.drive;
      if (drive == null) {
        throw new Error(
          "On a win32 FS, the options' `cwd()` must return a valid win32 " +
            'absolute path. This happened while trying to ' +
            `resolve: \`${filePath}\``,
        );
      }
      entNames = cwPath.entNames.concat(entNames);
    }
    return {drive, entNames};
  }

  /**
   * Implemented according with
   * http://man7.org/linux/man-pages/man7/path_resolution.7.html
   */
  _resolve(
    filePath: string,
    options?: {keepFinalSymlink: boolean, ...},
  ): Resolution {
    let keepFinalSymlink = false;
    if (options != null) {
      ({keepFinalSymlink} = options);
    }
    if (filePath === '') {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    const {drive, entNames} = this._parsePathWithCwd(filePath);
    checkPathLength(entNames, filePath);
    const root = this._getRoot(drive, filePath);
    const context = {
      drive,
      node: root,
      nodePath: [['', root]],
      entNames,
      symlinkCount: 0,
      keepFinalSymlink,
    };
    while (context.entNames.length > 0) {
      const entName = context.entNames.shift();
      this._resolveEnt(context, filePath, entName);
    }
    const {nodePath} = context;
    return {
      drive: context.drive,
      realpath: context.drive + nodePath.map(x => x[0]).join(this._pathSep),
      dirNode: (() => {
        const dirNode =
          nodePath.length >= 2
            ? nodePath[nodePath.length - 2][1]
            : context.node;
        if (dirNode == null || dirNode.type !== 'directory') {
          throw new Error('failed to resolve');
        }
        return dirNode;
      })(),
      node: context.node,
      basename: nullthrows(nodePath[nodePath.length - 1][0]),
      dirPath: nodePath
        .slice(0, -1)
        .map(nodePair => [nodePair[0], nullthrows(nodePair[1])]),
    };
  }

  /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's
   * LTI update could not be added via codemod */
  _resolveEnt(context, filePath: string, entName: any | string): void {
    const {node} = context;
    if (node == null) {
      throw makeError('ENOENT', filePath, 'no such file or directory');
    }
    if (node.type !== 'directory') {
      throw makeError('ENOTDIR', filePath, 'not a directory');
    }
    const {entries} = node;
    if (entName === '' || entName === '.') {
      return;
    }
    if (entName === '..') {
      const {nodePath} = context;
      if (nodePath.length > 1) {
        nodePath.pop();
        context.node = nodePath[nodePath.length - 1][1];
      }
      return;
    }
    const childNode = entries.get(entName);
    if (
      childNode == null ||
      childNode.type !== 'symbolicLink' ||
      (context.keepFinalSymlink && context.entNames.length === 0)
    ) {
      context.node = childNode;
      context.nodePath.push([entName, childNode]);
      return;
    }
    if (context.symlinkCount >= 10) {
      throw makeError('ELOOP', filePath, 'too many levels of symbolic links');
    }
    const {entNames, drive} = this._parsePath(childNode.target);
    if (drive != null) {
      context.drive = drive;
      context.node = this._getRoot(drive, filePath);
      context.nodePath = [['', context.node]];
    }
    context.entNames = entNames.concat(context.entNames);
    checkPathLength(context.entNames, filePath);
    ++context.symlinkCount;
  }

  _getRoot(drive: string, filePath: string): DirectoryNode {
    const root = this._roots.get(drive.toUpperCase());
    if (root == null) {
      throw makeError('ENOENT', filePath, `no such drive: \`${drive}\``);
    }
    return root;
  }

  _write(
    fd: number,
    buffer: Buffer,
    offset: number,
    length: number,
    position: ?number,
  ): number {
    const desc = this._getDesc(fd);
    if (!desc.writable) {
      throw makeError('EBADF', null, 'file descriptor cannot be written to');
    }
    if (position == null) {
      position = desc.position;
    }
    const {node} = desc;
    if (node.content.length < position + length) {
      const newBuffer = Buffer.alloc(position + length);
      node.content.copy(newBuffer, 0, 0, node.content.length);
      node.content = newBuffer;
    }
    buffer.copy(node.content, position, offset, offset + length);
    desc.position = position + length;
    return buffer.length;
  }

  _getFd(filePath: string, desc: Descriptor): number {
    let fd = 3;
    while (this._fds.has(fd)) {
      ++fd;
    }
    if (fd >= 256) {
      throw makeError('EMFILE', filePath, 'too many open files');
    }
    this._fds.set(fd, desc);
    return fd;
  }

  _getDesc(fd: number): Descriptor {
    const desc = this._fds.get(fd);
    if (desc == null) {
      throw makeError('EBADF', null, 'file descriptor is not open');
    }
    return desc;
  }

  _emitFileChange(
    nodePath: Array<[string, EntityNode]>,
    options: {eventType: 'rename' | 'change', ...},
  ): void {
    const fileNode = nodePath.pop();
    // $FlowFixMe[incompatible-use]
    let filePath = fileNode[0];
    let recursive = false;

    // $FlowFixMe[incompatible-use]
    for (const watcher of fileNode[1].watchers) {
      watcher.listener(options.eventType, filePath);
    }

    while (nodePath.length > 0) {
      const dirNode = nodePath.pop();
      // $FlowFixMe[incompatible-use]
      for (const watcher of dirNode[1].watchers) {
        if (recursive && !watcher.recursive) {
          continue;
        }
        watcher.listener(options.eventType, filePath);
      }
      // $FlowFixMe[incompatible-use]
      filePath = dirNode[0] + this._pathSep + filePath;
      recursive = true;
    }
  }
}

class Stats {
  _type: string;
  dev: number;
  mode: number;
  nlink: number;
  uid: number;
  gid: number;
  rdev: number;
  blksize: number;
  ino: number;
  size: number;
  blocks: number;
  atimeMs: number;
  mtimeMs: number;
  ctimeMs: number;
  birthtimeMs: number;
  atime: Date;
  mtime: Date;
  ctime: Date;
  birthtime: Date;

  /**
   * Don't keep a reference to the node as it may get mutated over time.
   */
  constructor(node: EntityNode) {
    this._type = node.type;
    this.dev = 1;
    this.mode = node.mode;
    this.nlink = 1;
    this.uid = node.uid;
    this.gid = node.gid;
    this.rdev = 0;
    this.blksize = 1024;
    this.ino = node.id;
    this.size =
      node.type === 'file'
        ? node.content.length
        : node.type === 'symbolicLink'
          ? node.target.length
          : 0;
    this.blocks = Math.ceil(this.size / 512);
    this.atimeMs = 1;
    this.mtimeMs = 1;
    this.ctimeMs = 1;
    this.birthtimeMs = 1;
    this.atime = new Date(this.atimeMs);
    this.mtime = new Date(this.mtimeMs);
    this.ctime = new Date(this.ctimeMs);
    this.birthtime = new Date(this.birthtimeMs);
  }

  isFile(): boolean {
    return this._type === 'file';
  }
  isDirectory(): boolean {
    return this._type === 'directory';
  }
  isBlockDevice(): boolean {
    return false;
  }
  isCharacterDevice(): boolean {
    return false;
  }
  isSymbolicLink(): boolean {
    return this._type === 'symbolicLink';
  }
  isFIFO(): boolean {
    return false;
  }
  isSocket(): boolean {
    return false;
  }
}

type ReadSync = (
  fd: number,
  buffer: Buffer,
  offset: number,
  length: number,
  position: ?number,
) => number;

class ReadFileSteam extends stream.Readable {
  _buffer: Buffer;
  _fd: number;
  _positions: ?{
    current: number,
    last: number,
    ...
  };
  _readSync: ReadSync;
  bytesRead: number;
  path: string | Buffer;

  constructor(options: {
    filePath: FilePath,
    encoding: ?Encoding,
    end: ?number,
    fd: number,
    highWaterMark: ?number,
    readSync: ReadSync,
    start: ?number,
    ...
  }) {
    const {highWaterMark, fd} = options;
    const superOptions: readableStreamOptions =
      highWaterMark != null ? {highWaterMark} : {};
    super(superOptions);
    this.bytesRead = 0;
    this.path = options.filePath;
    this._readSync = options.readSync;
    this._fd = fd;
    this._buffer = Buffer.alloc(1024);
    const {start, end} = options;
    if (start != null) {
      this._readSync(fd, Buffer.alloc(0), 0, 0, start);
    }
    if (end != null) {
      this._positions = {current: start || 0, last: end + 1};
    }
  }

  _read(size: any) {
    let bytesRead;
    const {_buffer} = this;
    do {
      const length = this._getLengthToRead();
      const position = this._positions && this._positions.current;
      bytesRead = this._readSync(this._fd, _buffer, 0, length, position);
      if (this._positions != null) {
        this._positions.current += bytesRead;
      }
      this.bytesRead += bytesRead;
    } while (this.push(bytesRead > 0 ? _buffer.slice(0, bytesRead) : null));
  }

  _getLengthToRead(): number {
    const {_positions, _buffer} = this;
    if (_positions == null) {
      return _buffer.length;
    }
    const leftToRead = Math.max(0, _positions.last - _positions.current);
    return Math.min(_buffer.length, leftToRead);
  }
}

type WriteSync = (
  fd: number,
  buffer: Buffer,
  offset: number,
  length: number,
  position?: number,
) => number;

class WriteFileStream extends stream.Writable {
  bytesWritten: number;
  path: string | Buffer;
  _fd: number;
  _writeSync: WriteSync;

  constructor(opts: {
    fd: number,
    filePath: FilePath,
    writeSync: WriteSync,
    start?: number,
    emitClose?: boolean,
    ...
  }) {
    super({emitClose: opts.emitClose, autoDestroy: true});
    this.path = opts.filePath;
    this.bytesWritten = 0;
    this._fd = opts.fd;
    this._writeSync = opts.writeSync;
    if (opts.start != null) {
      this._writeSync(opts.fd, Buffer.alloc(0), 0, 0, opts.start);
    }
  }

  /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's
   * LTI update could not be added via codemod */
  _write(buffer, encoding: any, callback) {
    try {
      const bytesWritten = this._writeSync(this._fd, buffer, 0, buffer.length);
      this.bytesWritten += bytesWritten;
    } catch (error) {
      callback(error);
      return;
    }
    callback();
  }
}

class FSWatcher extends EventEmitter {
  _encoding: Encoding;
  _node: EntityNode;
  _nodeWatcher: NodeWatcher;
  _persistIntervalId: IntervalID;

  constructor(
    node: EntityNode,
    options: {
      encoding: Encoding,
      recursive: boolean,
      persistent: boolean,
      ...
    },
  ) {
    super();
    this._encoding = options.encoding;
    this._nodeWatcher = {
      recursive: options.recursive,
      listener: this._listener,
    };
    node.watchers.push(this._nodeWatcher);
    this._node = node;
    if (options.persistent) {
      this._persistIntervalId = setInterval(() => {}, 60000);
    }
  }

  close() {
    this._node.watchers.splice(this._node.watchers.indexOf(this._nodeWatcher));
    clearInterval(this._persistIntervalId);
    this.emit('close');
  }

  _listener = (eventType: 'change' | 'rename', filePath: string) => {
    const encFilePath =
      this._encoding === 'buffer' ? Buffer.from(filePath, 'utf8') : filePath;
    try {
      this.emit('change', eventType, encFilePath);
    } catch (error) {
      this.close();
      this.emit('error', error);
    }
  };
}

class Dirent {
  +_stats: Stats;
  +name: string | Buffer;

  /**
   * Don't keep a reference to the node as it may get mutated over time.
   */
  constructor(node: EntityNode, name: string | Buffer) {
    this._stats = new Stats(node);
    this.name = name;
  }

  isBlockDevice(): boolean {
    return this._stats.isBlockDevice();
  }

  isCharacterDevice(): boolean {
    return this._stats.isCharacterDevice();
  }

  isDirectory(): boolean {
    return this._stats.isDirectory();
  }

  isFIFO(): boolean {
    return this._stats.isFIFO();
  }

  isFile(): boolean {
    return this._stats.isFile();
  }

  isSocket(): boolean {
    return this._stats.isSocket();
  }

  isSymbolicLink(): boolean {
    return this._stats.isSymbolicLink();
  }
}

function checkPathLength(
  entNames: Array<string> | Array<any | string>,
  filePath: string,
) {
  if (entNames.length > 32) {
    throw makeError(
      'ENAMETOOLONG',
      filePath,
      'file path too long (or one of the intermediate ' +
        'symbolic link resolutions)',
    );
  }
}

function pathStr(filePath: FilePath): string {
  if (typeof filePath === 'string') {
    return filePath;
  }
  return filePath.toString('utf8');
}

function makeError(code: string, filePath: ?string, message: string) {
  const err: $FlowFixMe = new Error(
    filePath != null
      ? `${code}: \`${filePath}\`: ${message}`
      : `${code}: ${message}`,
  );
  err.code = code;
  err.errno = constants[code];
  err.path = filePath;
  return err;
}

function nullthrows<T>(x: ?T): T {
  if (x == null) {
    throw new Error('item was null or undefined');
  }
  return x;
}

function getgid(): number {
  return process.getgid != null ? process.getgid() : -1;
}

function getuid(): number {
  return process.getuid != null ? process.getuid() : -1;
}

function parseFileMode(mode: string | number): number {
  if (typeof mode === 'string') {
    return Number.parseInt(mode, 8);
  }
  return mode;
}

module.exports = MemoryFs;
